原文地址: http://nullprogram.com/blog/2014/12/23/
我本人是交互式编程的狂热粉丝(比如 JavaScript, Java, Lisp, Clojure 都支持交互式编程). 也就是说可以在程序运行的同时修改和扩展程序. 对于那些非批处理(non-batch)的应用来说,交互式编程可以让测试和调优变得没那么无聊. 直到上周为止,我都一直不知道如何进行交互式C语言编程. 我们怎么可能做到重定义正在运行的C程序中的函数呢?
但是上周(21-25号),Casey Muratori为 Handmade Hero 这一游戏的引擎添加了交互式编程的能力. 交互式编程在游戏开发中特别的有用,因为开发者总是需要进行不停的调整(比如他可能需要为boss设置不同的战斗能力,看看效果),但是不想每次调整都需要重启整个游戏. 现在(交互式编程)这一功能被实现了. 其秘密在于,将几乎所有的应用都构建成为共享库.
这就对程序设计有了很严格的约束: 你不能把状态保存在全局变量或静态变量中,不是仅仅因为 这本来就不是一个好的编程风格. 更为关键的是,每次重新加载共享库都会重置全局变量.
甚至可能像 malloc()
这样的标准函数都不一定能用,这要看这些函数的具体实现和链接的方式.
若是以静态链接的方式链接C标准库,那么那些会操作全局变量的函数也不能被使用. 这就很难区分哪些函数能用,哪些函数不能用了.
而这种方法之所以能用在 Handmade Hero
中是因为该游戏的核心部分,同时也是作为动态库加载部分,并没有用到额外的库,包括标准库.
而且,在使用指向动态库中函数的函数指针时要特别小心,这些被指向的函数在重新加载动态库后就不再存在了. 这使得你很难把把交互式编程与 基于C的面向对象编程 整合起来用.
为了说明交互式编程的实现远离,我这里搞了个例子. 它是一个简单的基于 ncurses
实现的Life游戏. 你可以下载它的源代码并在类Unix系统上跑起来体验一下.
- 在终端下运行
make
编译代码, 然后运行./main
启动程序. 按r
生成随机的初始状态, 按q
退出程序. - 编辑
game.c
做一些修改. - 在另一个终端运行
make
. 你的修改就会立即在正在运行的程序中生效了.
As of this writing, Handmade Hero is being written on Windows, so Casey is using a DLL and the Win32 API, but the same technique can be applied on Linux, or any other Unix-like system, using libdl. That’s what I’ll be using here.
The program will be broken into two parts: the Game of Life shared library (“game”) and a wrapper (“main”) whose job is only to load the shared library, reload it when it updates, and call it at a regular interval. The wrapper is agnostic about the operation of the “game” portion, so it could be re-used almost untouched in another project.
To avoid maintaining a whole bunch of function pointer assignments in several places, the API to the “game” is enclosed in a struct. This also eliminates warnings from the C compiler about mixing data and function pointers. The layout and contents of the game_state struct is private to the game itself. The wrapper will only handle a pointer to this struct.
struct game_state;
struct game_api {
struct game_state *(*init)();
void (*finalize)(struct game_state *state);
void (*reload)(struct game_state *state);
void (*unload)(struct game_state *state);
bool (*step)(struct game_state *state);
};
In the demo the API is made of 5 functions. The first 4 are primarily concerned with loading and unloading.
- init(): Allocate and return a state to be passed to every other API call. This will be called once when the program starts and never again, even after reloading. If we were concerned about using malloc() in the shared library, the wrapper would be responsible for performing the actual memory allocation.
- finalize(): The opposite of init(), to free all resources held by the game state.
- reload(): Called immediately after the library is reloaded. This is the chance to sneak in some additional initialization in the running program. Normally this function will be empty. It’s only used temporarily during development.
- unload(): Called just before the library is unloaded, before a new version is loaded. This is a chance to prepare the state for use by the next version of the library. This can be used to update structs and such, if you wanted to be really careful. This would also normally be empty.
- step(): Called at a regular interval to run the game. A real game will likely have a few more functions like this.
The library will provide a filled out API struct as a global variable, GAME_API. This is the only exported symbol in the entire shared library! All functions will be declared static, including the ones referenced by the structure.
const struct game_api GAME_API = {
.init = game_init,
.finalize = game_finalize,
.reload = game_reload,
.unload = game_unload,
.step = game_step
};
The wrapper is focused on calling dlopen(), dlsym(), and dlclose() in the right order at the right time. The game will be compiled to the file libgame.so, so that’s what will be loaded. It’s written in the source with a . / to force the name to be used as a filename. The wrapper keeps track of everything in a game struct.
const char *GAME_LIBRARY = "./libgame.so";
struct game {
void *handle;
ino_t id;
struct game_api api;
struct game_state *state;
};
The handle is the value returned by dlopen(). The id is the inode of the shared library, as returned by stat(). The rest is defined above. Why the inode? We could use a timestamp instead, but that’s indirect. What we really care about is if the shared object file is actually a different file than the one that was loaded. The file will never be updated in place, it will be replaced by the compiler/linker, so the timestamp isn’t what’s important.
Using the inode is a much simpler situation than in Handmade Hero. Due to Windows’ broken file locking behavior, the game DLL can’t be replaced while it’s being used. To work around this limitation, the build system and the loader have to rely on randomly-generated filenames.
void game_load(struct game *game)
The purpose of the game_load() function is to load the game API into a game struct, but only if either it hasn’t been loaded yet or if it’s been updated. Since it has several independent failure conditions, let’s examine it in parts.
struct stat attr;
if ((stat(GAME_LIBRARY, &attr) == 0) && (game->id != attr.st_ino)) {
First, use stat() to determine if the library’s inode is different than the one that’s already loaded. The id field will be 0 initially, so as long as stat() succeeds, this will load the library the first time.
if (game->handle) {
game->api.unload(game->state);
dlclose(game->handle);
}
If a library is already loaded, unload it first, being sure to call unload() to inform the library that it’s being updated. It’s critically important that dlclose() happens before dlopen(). On my system, dlopen() looks only at the string it’s given, not the file behind it. Even though the file has been replaced on the filesystem, dlopen() will see that the string matches a library already opened and return a pointer to the old library. (Is this a bug?) The handles are reference counted internally by libdl.
void *handle = dlopen(GAME_LIBRARY, RTLD_NOW);
Finally load the game library. There’s a race condition here that cannot be helped due to limitations of dlopen(). The library may have been updated again since the call to stat(). Since we can’t ask dlopen() about the inode of the library it opened, we can’t know. But as this is only used during development, not in production, it’s not a big deal.
if (handle) {
game->handle = handle;
game->id = attr.st_ino;
/* ... more below ... */
} else {
game->handle = NULL;
game->id = 0;
}
If dlopen() fails, it will return NULL. In the case of ELF, this will happen if the compiler/linker is still in the process of writing out the shared library. Since the unload was already done, this means no game will be loaded when game_load returns. The user of the struct needs to be prepared for this eventuality. It will need to try loading again later (i.e. a few milliseconds). It may be worth filling the API with stub functions when no library is loaded.
const struct game_api *api = dlsym(game->handle, "GAME_API");
if (api != NULL) {
game->api = *api;
if (game->state == NULL)
game->state = game->api.init();
game->api.reload(game->state);
} else {
dlclose(game->handle);
game->handle = NULL;
game->id = 0;
}
When the library loads without error, look up the GAME_API struct that was mentioned before and copy it into the local struct. Copying rather than using the pointer avoids one more layer of redirection when making function calls. The game state is initialized if it hasn’t been already, and the reload() function is called to inform the game it’s just been reloaded.
If looking up the GAME_API fails, close the handle and consider it a failure.
The main loop calls game_load() each time around. And that’s it!
int main(void)
{
struct game game = {0};
for (;;) {
game_load(&game);
if (game.handle)
if (!game.api.step(game.state))
break;
usleep(100000);
}
game_unload(&game);
return 0;
}
Now that I have this technique in by toolbelt, it has me itching to develop a proper, full game in C with OpenGL and all, perhaps in another Ludum Dare. The ability to develop interactively is very appealing.